Micron Document
🎖️GitЯра🎖️

Commit 250edf1ad9e84217bafa8edea8f39b8bb9faddf1


Parents : 9e458ca
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-03T13:48:05-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-03T18:48:05Z

fix(desktop): restore BLE scanning and connecting in packaged builds (#6558)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
index 01c4df93c0..5085a8f5b5 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
@@ -24,6 +24,9 @@ import com.juul.kable.PooledThreadingStrategy
import com.juul.kable.toIdentifier
import org.meshtastic.core.model.util.anonymize
+/** Android's scanner filters on address in hardware, so Kable's `Filter.Address` works natively here. */
+internal actual val supportsNativeAddressScanFilter: Boolean = true
+
/**
* Shared thread pool for Kable BLE connections.
*

diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
index a96d4dafb9..56191b671f 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
@@ -42,8 +43,19 @@ internal sealed interface KableScanFilter {
internal data class KableScanResult(val identifier: String, val name: String?, val advertisement: Advertisement?)
-internal fun resolveKableScanFilter(serviceUuid: Uuid?, address: String?): KableScanFilter = when {
- address != null -> KableScanFilter.Address(address)
+/**
+ * Picks the native filter to hand Kable: address only where the platform honours it
+ * ([supportsNativeAddressScanFilter]), otherwise the service UUID, since a filter the platform ignores matches nothing.
+ * [KableBleScanner.scan] narrows to the address client-side either way.
+ *
+ * [supportsAddressFilter] is a parameter so both platform behaviours are reachable from commonTest.
+ */
+internal fun resolveKableScanFilter(
+ serviceUuid: Uuid?,
+ address: String?,
+ supportsAddressFilter: Boolean = supportsNativeAddressScanFilter,
+): KableScanFilter = when {
+ address != null && supportsAddressFilter -> KableScanFilter.Address(address)
serviceUuid != null -> KableScanFilter.ServiceUuid(serviceUuid)
else -> KableScanFilter.None
}
@@ -78,7 +90,7 @@ open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleSca
// common supertype below Exception, so merging them would mean catching Exception broadly instead.
@Suppress("ThrowsCount")
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
- val filter = resolveKableScanFilter(serviceUuid = serviceUuid, address = address)
+ val nativeFilter = resolveKableScanFilter(serviceUuid = serviceUuid, address = address)
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
@@ -86,15 +98,20 @@ open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleSca
withTimeoutOrNull(timeout) {
reserveScanStart()
try {
- advertisements(filter).collect { advertisement ->
- send(
- MeshtasticBleDevice(
- address = advertisement.identifier,
- name = advertisement.name,
- advertisement = advertisement.advertisement,
- ),
- )
- }
+ // Re-check the address even when the native filter already covers it: callers such as
+ // NymeaWifiService take the first emission without their own address check, so an unsupported
+ // native address filter must never widen the scan to other devices.
+ advertisements(nativeFilter)
+ .filter { address == null || it.identifier.equals(address, ignoreCase = true) }
+ .collect { advertisement ->
+ send(
+ MeshtasticBleDevice(
+ address = advertisement.identifier,
+ name = advertisement.name,
+ advertisement = advertisement.advertisement,
+ ),
+ )
+ }
} catch (ex: CancellationException) {
throw ex
} catch (ex: UnmetRequirementException) {

diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
index 0992a53011..171f6286bf 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
@@ -19,6 +19,12 @@ package org.meshtastic.core.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
+/**
+ * Whether Kable honours a scan filter on device address here. Android only: `Filter.Address` throws on Apple/JS, and
+ * the JVM/btleplug backend evaluates predicates with a hardcoded null address so it matches nothing.
+ */
+internal expect val supportsNativeAddressScanFilter: Boolean
+
/** Platform-specific configuration for the Peripheral builder based on device type. */
internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean)

diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt
index 8fbf697e22..85ae358dda 100644
--- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt
+++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.ble
+import app.cash.turbine.test
import com.juul.kable.Advertisement
import dev.mokkery.MockMode
import dev.mokkery.mock
@@ -132,22 +133,91 @@ class KableBleConnectionTest {
}
@Test
- fun `address filter is applied`() = runTest {
- val scanner = TestKableBleScanner(scanResults = emptyFlow())
+ fun `address filter is used natively only where the platform supports it`() {
+ val address = "AA:BB:CC:DD:EE:FF"
+
+ assertEquals(
+ KableScanFilter.Address(address),
+ resolveKableScanFilter(serviceUuid = null, address = address, supportsAddressFilter = true),
+ )
+ // Without native support the scan must stay unfiltered rather than carry a filter that matches nothing.
+ assertEquals(
+ KableScanFilter.None,
+ resolveKableScanFilter(serviceUuid = null, address = address, supportsAddressFilter = false),
+ )
+ }
- scanner.scan(timeout = 1.seconds, address = "AA:BB:CC:DD:EE:FF").toList()
+ @Test
+ fun `service uuid wins over address when the platform cannot filter by address`() {
+ val serviceUuid = Uuid.parse("12345678-1234-1234-1234-1234567890ab")
+ val address = "AA:BB:CC:DD:EE:FF"
+
+ assertEquals(
+ KableScanFilter.Address(address),
+ resolveKableScanFilter(serviceUuid = serviceUuid, address = address, supportsAddressFilter = true),
+ )
+ // Kable's btleplug backend matches address filters against a hardcoded null, so an address filter here would
+ // silently yield no advertisements at all and BLE connect could never find the device.
+ assertEquals(
+ KableScanFilter.ServiceUuid(serviceUuid),
+ resolveKableScanFilter(serviceUuid = serviceUuid, address = address, supportsAddressFilter = false),
+ )
+ }
- assertEquals(KableScanFilter.Address("AA:BB:CC:DD:EE:FF"), scanner.lastFilter)
+ @Test
+ fun `scan narrows to the requested address regardless of the native filter`() = runTest {
+ val wanted = "AA:BB:CC:DD:EE:FF"
+ val scanner =
+ TestKableBleScanner(
+ scanResults =
+ flowOf(
+ KableScanResult(identifier = "11:22:33:44:55:66", name = "Other", advertisement = null),
+ KableScanResult(identifier = wanted, name = "Meshtastic", advertisement = null),
+ KableScanResult(identifier = "77:88:99:AA:BB:CC", name = "Another", advertisement = null),
+ ),
+ )
+
+ scanner.scan(timeout = 1.seconds, address = wanted).test {
+ assertEquals(wanted, awaitItem().address)
+ awaitComplete()
+ }
}
@Test
- fun `address filter takes priority over service uuid`() = runTest {
- val serviceUuid = Uuid.parse("12345678-1234-1234-1234-1234567890ab")
- val scanner = TestKableBleScanner(scanResults = emptyFlow())
+ fun `scan matches the requested address case-insensitively`() = runTest {
+ // A second, non-matching advertisement keeps this honest: a scan that ignored the address entirely would
+ // emit both, so the assertion proves the filter ran *and* that it matched across case.
+ val scanner =
+ TestKableBleScanner(
+ scanResults =
+ flowOf(
+ KableScanResult(identifier = "11:22:33:44:55:66", name = "Other", advertisement = null),
+ KableScanResult(identifier = "aa:bb:cc:dd:ee:ff", name = "Meshtastic", advertisement = null),
+ ),
+ )
+
+ scanner.scan(timeout = 1.seconds, address = "AA:BB:CC:DD:EE:FF").test {
+ assertEquals("aa:bb:cc:dd:ee:ff", awaitItem().address)
+ awaitComplete()
+ }
+ }
- scanner.scan(timeout = 1.seconds, serviceUuid = serviceUuid, address = "AA:BB:CC:DD:EE:FF").toList()
+ @Test
+ fun `scan without an address emits every advertisement`() = runTest {
+ val scanner =
+ TestKableBleScanner(
+ scanResults =
+ flowOf(
+ KableScanResult(identifier = "11:22:33:44:55:66", name = "One", advertisement = null),
+ KableScanResult(identifier = "77:88:99:AA:BB:CC", name = "Two", advertisement = null),
+ ),
+ )
- assertEquals(KableScanFilter.Address("AA:BB:CC:DD:EE:FF"), scanner.lastFilter)
+ scanner.scan(timeout = 1.seconds).test {
+ assertEquals("11:22:33:44:55:66", awaitItem().address)
+ assertEquals("77:88:99:AA:BB:CC", awaitItem().address)
+ awaitComplete()
+ }
}
@Test

diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
index a9e1501af2..6e9baf4d11 100644
--- a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
+++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
@@ -19,6 +19,9 @@ package org.meshtastic.core.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
+// Kable's `Filter.Address` throws UnsupportedOperationException on Apple.
+internal actual val supportsNativeAddressScanFilter: Boolean = false
+
/** No-op stubs for iOS target in core:ble. */
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// No-op for stubs

diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
index 450d835821..0b4b4ae85f 100644
--- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
+++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
@@ -20,6 +20,10 @@ import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
import com.juul.kable.toIdentifier
+// Kable's btleplug backend evaluates scan filters with a hardcoded `address = null`, so an address filter matches
+// nothing and the scan yields no advertisements at all.
+internal actual val supportsNativeAddressScanFilter: Boolean = false
+
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// Desktop Kable uses direct connections without needing autoConnect.
}

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index fe121a7f72..611ea9900d 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -290,9 +290,8 @@ class BleRadioTransport(
*/
private suspend fun scanForFreshDevice(timeout: Duration): BleDevice? = try {
withTimeoutOrNull(timeout) {
- // Pass both service UUID and address so the scanner can apply the most efficient platform filter.
- // Android uses address (OS-level HW filter), while CoreBluetooth (macOS) needs the service UUID because
- // it caches peripheral identifiers and may not re-report by address alone.
+ // Pass both service UUID and address; the scanner picks whichever filter the platform can honour
+ // (address natively on Android, service UUID elsewhere) and narrows to the address itself.
scanner.scan(timeout = timeout, serviceUuid = SERVICE_UUID, address = address).first {
it.address.equals(address, ignoreCase = true)
}

diff --git a/desktopApp/proguard-rules.pro b/desktopApp/proguard-rules.pro
index 42855ac00b..5cf56f7760 100644
--- a/desktopApp/proguard-rules.pro
+++ b/desktopApp/proguard-rules.pro
@@ -40,12 +40,21 @@
-dontwarn sun.misc.Unsafe
-dontwarn java.lang.invoke.**
-# ---- JNA (Java Native Access) — used by LinuxNotificationSender for libnotify ---
+# ---- JNA (Java Native Access) — libnotify sender + kable's btleplug BLE bindings ---
# JNA uses reflection to bind native methods; keep its core and callback classes.
-keep class com.sun.jna.** { *; }
-keep class com.sun.jna.ptr.** { *; }
-dontwarn com.sun.jna.**
+# JNA callbacks are only ever invoked from native code, so the shrinker sees the
+# abstract methods as unused and empties the interface. JNA then can't resolve
+# the callback method and rejects the whole vtable Structure at <clinit>:
+# IllegalArgumentException: Structure field "uniffiFree" was declared as
+# ...UniffiCallbackInterfaceFree, which is not supported within a Structure
+# which killed BLE scanning in every packaged desktop build (#6553).
+-keep class * implements com.sun.jna.Callback { *; }
+-keep class * extends com.sun.jna.Structure { *; }
+
# ---- jSerialComm Android stubs (cross-platform serial library) --------------
# jSerialComm bundles Android shims that reference android.* classes; harmless
# on JVM/desktop but ProGuard fails the build on unresolved program classes

Served by rngit 1.5.0 - Generated in 0.13s